Supplementary Material (B) - Testing the Circumplex Structure of the Soundscape Survey

Accompanying the paper: “Soundscape descriptors in eighteen languages: translation and validation through listening experiments

Authors
Affiliation

Andrew Mitchell

University College London

Francesco Aletta

University College London

Published

November 21, 2023

1 Setup

Code
# Import the required packages
import pandas as pd
import seaborn as sns
from pathlib import Path
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit
import numpy as np
from datetime import datetime
import circumplex
import json


today = datetime.today().strftime('%Y-%m-%d')
Code
# Define the scales and angles to be used
scales = ["PAQ1", "PAQ2", "PAQ3", "PAQ4", "PAQ5", "PAQ6", "PAQ7", "PAQ8"]
eq_angles = [0, 45, 90, 135, 180, 225, 270, 315]

# Define the data and output folders
data_folder = Path("../data/")
output_folder = Path(f"../outputs/{today}")

# Load data
satp = pd.read_excel(data_folder / "SATP Dataset v1.4.xlsx")

lvls = pd.read_excel(data_folder / "LLAN.xlsx")
# Clean up the lvls data
lvls = lvls.groupby("Mark/Group Name").max().drop("Channel Name", axis=1)
lvls.rename(columns={"L/dB(SPL)": "max_Leq", "L(A)/dB(SPL)": "max_LAeq", "N/soneGF": "max_N", "L90(A)/dB(SPL)": "max_LA90"}, inplace=True)

# Add the levels to the satp data
satp = satp.merge(lvls, left_on="Recording", right_on = "Mark/Group Name", right_index=True)

# Load the results from the latest SEM analysis
sem_res = pd.read_csv(output_folder / "sem-fit-ipsatized.csv")
sem_res.drop("Unnamed: 0", axis=1, inplace=True)

# In some cases, the SEM flips the angles (i.e. vibrant is at 315 degrees instead of 45).
# This function checks for this and corrects it, to ensure all the scales are in the 
# correct order, but without changing the relationship between the angles, as identified by the SEM.)

# First, get the angles from the SEM results
ang_df = sem_res[sem_res['Model Type'] == 'Equal comm.'][["Language"] + scales]
ang_df.set_index("Language", inplace=True)


def check_inverse_angles(language_angles):
    """Check if the angles are inverse"""
    if language_angles[1] > language_angles[2] or language_angles[2] > language_angles[3]:
        return True
    else:
        return False

# Then, check if the angles are inverse, and if so, correct them
for lang in ang_df.index:
    if check_inverse_angles(ang_df.loc[lang].values):
        ang_df.loc[lang][1:] = 360 - ang_df.loc[lang][1:]

ang_dict = ang_df.T.to_dict(orient="list")
ang_dict

2 Calculate the SEM fit score

Code
# Define the thresholds for the SEM fit criteria
thresholds = {
    "p": 0.05,
    "CFI": 0.92, # ours
    # "CFI": 0.9, # Rogoza
    "GFI": 0.9,
    "AGFI": 0.85,
    "SRMR": 0.08,
    "MCSC": -0.7,
    # "RMSEA": 0.08, # ours
    "RMSEA": 0.13, # Rogoza
    "GDIFF": 25,
}

# Choose which criteria to include in the final score
# incl_in_score = ['p', 'CFI', 'GFI', 'SRMR', 'MCSC'] # ours
# incl_in_score = ['p', 'CFI', 'GFI', 'AGFI', 'RMSEA'] # Rogoza
incl_in_score = ['p', 'CFI', 'GFI', 'AGFI', 'SRMR'] # mixed

# Define the thresholds for the final score
pass_thresh = 5
tent_thresh = 4

# Calculate the final score
sem_res['p_pass'] = sem_res['p'] <= thresholds['p']
sem_res['CFI_pass'] = sem_res['CFI'] >= thresholds['CFI']
sem_res['GFI_pass'] = sem_res['GFI'] >= thresholds['GFI']
sem_res['AGFI_pass'] = sem_res['AGFI'] >= thresholds['AGFI']
sem_res['SRMR_pass'] = sem_res['SRMR'] <= thresholds['SRMR']
# sem_res['MCSC_pass'] = sem_res['MCSC'] <= thresholds['MCSC']
# sem_res['RMSEA_pass'] = sem_res['RMSEA'] <= thresholds['RMSEA']
# sem_res['GDIFF_pass'] = sem_res['GDIFF'] <= thresholds['GDIFF']

sem_res['Score'] = sem_res[[x + '_pass' for x in incl_in_score]].sum(axis=1)
sem_res['Score'] = sem_res['Score'].astype(int)
sem_res['passing'] = pd.cut(sem_res['Score'], bins=[0, tent_thresh, pass_thresh, 7], labels=['Fail', 'Tentative', 'Pass'], right=False)
# Save the results
sem_res.to_excel(output_folder / f"{today}_sem-fit-results-Rogoza.xlsx", index=False)

sem_res[["Language", "Model Type", "Score", "passing"]].loc[sem_res["Model Type"] == "Equal comm."].sort_values("Score", ascending=False)
Language Model Type Score passing
33 ita Equal comm. 5 Pass
5 arb Equal comm. 5 Pass
45 nld Equal comm. 5 Pass
41 kor Equal comm. 5 Pass
65 vie Equal comm. 5 Pass
17 ell Equal comm. 5 Pass
9 cmn Equal comm. 5 Pass
25 hrv Equal comm. 4 Tentative
29 ind Equal comm. 4 Tentative
13 deu Equal comm. 4 Tentative
53 spa Equal comm. 4 Tentative
57 swe Equal comm. 4 Tentative
61 tur Equal comm. 4 Tentative
1 eng Equal comm. 4 Tentative
21 fra Equal comm. 3 Fail
49 por Equal comm. 3 Fail
37 jpn Equal comm. 2 Fail

3 Structural Summary Method Analysis

Code
# Perform the SSM analysis
passing = sem_res.loc[sem_res["Model Type"] == "Equal comm."].query("passing != 'Fail'")['Language'].values

satp = satp.query("Language in @passing")

eq_res = circumplex.ssm_analyse(satp, scales, ['max_N'], grouping=['Language'], angles=eq_angles)
corr_res = circumplex.ssm_analyse(satp, scales, ['max_N'], grouping=['Language'], grouped_angles=ang_dict)

eq_res.plot()
corr_res.plot()
(<Figure size 672x480 with 1 Axes>, <PolarAxes: >)

Code
# Combine the equal angle and corrected angle results
eq_df = eq_res.table
eq_df["model"] = "Equal Angles"

corr_df = corr_res.table
corr_df["model"] = "Corrected Angles"

res_df = pd.concat([eq_df, corr_df], axis=0)
res_df.reset_index(drop=True, inplace=True)
res_df.sort_values("label", inplace=True)
res_df.to_csv(output_folder / f"{today}_ssm-fit.csv")
Code
res_df
label group measure elevation xval yval amplitude displacement r2 PAQ1 PAQ2 PAQ3 PAQ4 PAQ5 PAQ6 PAQ7 PAQ8 model
0 arb_max_N arb max_N 0.039877 -0.373980 0.325830 0.496010 138.935974 0.868454 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
14 arb_max_N arb max_N 0.028652 -0.300776 0.526289 0.606173 119.748102 0.973883 0.0 36.0 45.0 135.0 167.0 201.0 242.0 308.0 Corrected Angles
1 cmn_max_N cmn max_N 0.013304 -0.386767 0.075731 0.394111 168.921404 0.914363 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
15 cmn_max_N cmn max_N 0.008257 -0.302994 0.345729 0.459710 131.231028 0.997280 0.0 18.0 38.0 154.0 171.0 196.0 217.0 318.0 Corrected Angles
2 deu_max_N deu max_N -0.038871 -0.470273 0.379643 0.604388 141.086697 0.985716 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
16 deu_max_N deu max_N -0.012336 -0.552609 0.239104 0.602119 156.602687 0.997791 0.0 64.0 97.0 132.0 182.0 254.0 282.0 336.0 Corrected Angles
3 ell_max_N ell max_N 0.019303 -0.392457 0.376413 0.543792 136.195463 0.865822 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
17 ell_max_N ell max_N -0.012408 -0.447707 0.337784 0.560838 142.966368 0.965840 0.0 72.0 86.0 133.0 161.0 233.0 267.0 328.0 Corrected Angles
4 eng_max_N eng max_N -0.007631 -0.492460 0.363272 0.611951 143.584998 0.982702 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
18 eng_max_N eng max_N -0.002989 -0.503990 0.322928 0.598572 147.350541 0.980108 0.0 46.0 94.0 138.0 177.0 231.0 275.0 340.0 Corrected Angles
5 hrv_max_N hrv max_N 0.038885 -0.423038 0.424883 0.599572 134.875379 0.906281 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
19 hrv_max_N hrv max_N 0.001776 -0.535124 0.309361 0.618112 149.967353 0.995544 0.0 84.0 93.0 160.0 173.0 243.0 273.0 354.0 Corrected Angles
6 ind_max_N ind max_N 0.065714 -0.324460 0.484945 0.583478 123.785093 0.966319 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
20 ind_max_N ind max_N -0.003039 -0.219705 0.484667 0.532140 114.385256 0.994985 0.0 53.0 104.0 123.0 139.0 202.0 284.0 308.0 Corrected Angles
21 ita_max_N ita max_N 0.043825 -0.533336 0.201253 0.570044 159.326103 0.944581 0.0 57.0 104.0 142.0 170.0 274.0 285.0 336.0 Corrected Angles
7 ita_max_N ita max_N -0.001906 -0.456831 0.392617 0.602364 139.323087 0.920940 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
8 kor_max_N kor max_N 0.003372 -0.330771 0.487729 0.589312 124.144557 0.962188 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
22 kor_max_N kor max_N -0.001199 -0.352948 0.422768 0.550731 129.856771 0.995467 0.0 56.0 90.0 124.0 151.0 251.0 275.0 288.0 Corrected Angles
23 nld_max_N nld max_N -0.004002 -0.542282 0.314914 0.627089 149.855424 0.998637 0.0 43.0 111.0 125.0 174.0 257.0 307.0 341.0 Corrected Angles
9 nld_max_N nld max_N -0.063067 -0.485426 0.476910 0.680501 135.507011 0.955705 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
24 spa_max_N spa max_N 0.013359 -0.476590 0.378525 0.608621 141.542150 0.979561 0.0 41.0 103.0 147.0 174.0 238.0 279.0 332.0 Corrected Angles
10 spa_max_N spa max_N -0.000127 -0.471657 0.440156 0.645134 136.978653 0.982702 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
25 swe_max_N swe max_N 0.001115 -0.547474 0.293851 0.621350 151.775732 0.991721 0.0 66.0 87.0 146.0 175.0 249.0 275.0 335.0 Corrected Angles
11 swe_max_N swe max_N -0.009718 -0.475225 0.397112 0.619303 140.116863 0.959252 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
26 tur_max_N tur max_N 0.012860 -0.424515 0.448116 0.617269 133.450768 0.971465 0.0 55.0 97.0 106.0 157.0 254.0 289.0 313.0 Corrected Angles
12 tur_max_N tur max_N -0.010102 -0.364721 0.557398 0.666119 123.197809 0.930390 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
13 vie_max_N vie max_N -0.003637 -0.367998 0.086214 0.377963 166.814604 0.835385 0.0 45.0 90.0 135.0 180.0 225.0 270.0 315.0 Equal Angles
27 vie_max_N vie max_N -0.027039 -0.387283 0.163897 0.420536 157.062077 0.978702 0.0 68.0 58.0 155.0 180.0 234.0 228.0 322.0 Corrected Angles
Code
fig, axes = plt.subplots(9, 2, figsize=(12, 9*4), sharey=True)
eq_res.profile_plots(axes=axes);
plt.show()

Code
fig, axes = plt.subplots(9, 2, figsize=(12, 9*4), sharey=True)
corr_res.profile_plots(axes=axes);
plt.show()

4 Placing circumplex items in the circumplex

Code
from matplotlib import colormaps

def plot_circumplex(scale, reduced_eq_results: circumplex.SSMResults, reduced_corr_results: circumplex.SSMResults):

    fig, ax = plt.subplots(1, 2, figsize=(10, 5), subplot_kw={"projection": "polar"})
    colors = colormaps.get_cmap("tab20").colors
    colors = iter(colors)

    for res in reduced_eq_res.results:
        ax[0].plot(
            np.deg2rad(res.displacement),
            res.amplitude,
            color=next(colors),
            marker="o",
            markersize=10,
            label = res.label,
        )
    ax[0].set_title("Equal Angles")

    colors = colormaps.get_cmap("tab20").colors
    colors = iter(colors)
    for res in reduced_corr_res.results:
        ax[1].plot(
            np.deg2rad(res.displacement),
            res.amplitude,
            color=next(colors),
            marker="o",
            markersize=10,
            label = res.label
        )
    ax[1].set_title("Corrected Angles")
    ax[1].legend(bbox_to_anchor=(1.1, 1.1))

    plt.suptitle(scale)
    plt.tight_layout()



eq_locating_lists = []
corr_locating_lists = []

for scale in scales:

    index = scales.index(scale)
    reduced_scales = scales[:index] + scales[index+1:]
    reduced_eq_angles = eq_angles[:index] + eq_angles[index+1:]
    reduced_corr_angles = {lang: ang_dict[lang][:index] + ang_dict[lang][index+1:] for lang in passing}

    reduced_eq_res = circumplex.ssm_analyse(satp, reduced_scales, [scale], grouping=['Language'], angles=reduced_eq_angles)
    reduced_corr_res = circumplex.ssm_analyse(satp, reduced_scales, [scale], grouping=['Language'], grouped_angles=reduced_corr_angles)

    plot_circumplex(scale, reduced_eq_res, reduced_corr_res)

    eq_locating_lists.append(reduced_eq_res)
    corr_locating_lists.append(reduced_corr_res)

Code
def transpose_results_lists_to_ssm_results(list_of_results):
    # Initialize a dictionary to hold the lists
    results_dict = {group: [] for group in list_of_results[0].groups}

    for ssm_res in list_of_results:
        for ssm_param in ssm_res.results:
            # Check if the group exists in the dictionary
            if ssm_param.group in results_dict:
                # Append the ssm_param to the appropriate list in the dictionary
                results_dict[ssm_param.group].append(ssm_param)

    return results_dict

per_lang_eq_locating_lists = transpose_results_lists_to_ssm_results(eq_locating_lists)
per_lang_corr_locating_lists = transpose_results_lists_to_ssm_results(corr_locating_lists)

for key, val in per_lang_eq_locating_lists.items():
    per_lang_eq_locating_lists[key] = circumplex.SSMResults(val)

for key, val in per_lang_corr_locating_lists.items():
    per_lang_corr_locating_lists[key] = circumplex.SSMResults(val)


per_lang_eq_locating_lists['eng'].plot()
per_lang_corr_locating_lists['eng'].plot()
(<Figure size 672x480 with 1 Axes>, <PolarAxes: >)

Code
from scipy.spatial import procrustes

def test_rogoza_procrustes():
    theor_disp = [225, 270, 315]
    emp_disp = [216.4, 267.2, 329.9]
    data2 = np.round(np.array((np.sin(np.deg2rad(emp_disp)), np.cos(np.deg2rad(emp_disp)))), 3).T
    data1 = np.round(np.array((np.sin(np.deg2rad(theor_disp)), np.cos(np.deg2rad(theor_disp)))), 3).T
    print("Empirical sine/cosine values")
    print(data2)
    print("Theoretical sine/cosine values")
    print(data1)

    mtx1, mtx2, disparity = procrustes(data1, data2)
    print("Congruence: ", 1 - disparity)

# test_rogoza_procrustes()

# print("=============================/n")


def procrustes_congruence(lang_reduced_corr_res_table, target_angles=eq_angles):

    data2 = np.array((np.sin(np.deg2rad(lang_reduced_corr_res_table['displacement'])), np.cos(np.deg2rad(lang_reduced_corr_res_table['displacement'])))).T

    data1 = np.ones_like(data2)
    data1[:, 0] = np.sin(np.deg2rad(target_angles))
    data1[:, 1] = np.cos(np.deg2rad(target_angles))

    # print("Empirical sine/cosine values")
    # print(data2)
    # print("Theoretical sine/cosine values")
    # print(data1)

    mtx1, mtx2, disparity = procrustes(data1, data2)
    return np.round(1 - disparity, 3)

for lang in passing:
    print("=============================")
    print(lang)
    print("Equal Angles:     ", procrustes_congruence(per_lang_eq_locating_lists[lang].table))
    print("Corrected Angles: ", procrustes_congruence(per_lang_corr_locating_lists[lang].table))
=============================
eng
Equal Angles:      0.983
Corrected Angles:  0.985
=============================
arb
Equal Angles:      0.88
Corrected Angles:  0.934
=============================
cmn
Equal Angles:      0.658
Corrected Angles:  0.776
=============================
deu
Equal Angles:      0.959
Corrected Angles:  0.976
=============================
ell
Equal Angles:      0.939
Corrected Angles:  0.953
=============================
hrv
Equal Angles:      0.899
Corrected Angles:  0.907
=============================
ind
Equal Angles:      0.818
Corrected Angles:  0.874
=============================
ita
Equal Angles:      0.926
Corrected Angles:  0.952
=============================
kor
Equal Angles:      0.816
Corrected Angles:  0.907
=============================
nld
Equal Angles:      0.86
Corrected Angles:  0.909
=============================
spa
Equal Angles:      0.955
Corrected Angles:  0.968
=============================
swe
Equal Angles:      0.953
Corrected Angles:  0.963
=============================
tur
Equal Angles:      0.818
Corrected Angles:  0.908
=============================
vie
Equal Angles:      0.73
Corrected Angles:  0.762

5 German locations

Code
deu_data = pd.read_excel(
    data_folder / "SATP Dataset v1.4.xlsx",
    sheet_name="deu TUB",
    usecols=["Participant", "Recording", 
            "PAQ1", "PAQ2", "PAQ3", "PAQ4", "PAQ5", "PAQ6", "PAQ7", "PAQ8",
            "abwechslungsreich", # PAQ 2 alt
            "dynamisch", # PAQ3 alt
            "chaotisch", # PAQ4 alt
            "störend", # PAQ5 alt
            "monoton", # PAQ6 alt
            "statisch", # PAQ7 alt
            "erholsam", # PAQ8 alt
        ]
    )

deu_data.rename(columns={
    "abwechslungsreich": "PAQ2_alt",
    "dynamisch": "PAQ3_alt",
    "chaotisch": "PAQ4_alt",
    "störend": "PAQ5_alt",
    "monoton": "PAQ6_alt",
    "statisch": "PAQ7_alt",
    "erholsam": "PAQ8_alt",
}, inplace=True)

alt_scales = ["PAQ2_alt", "PAQ3_alt", "PAQ4_alt", "PAQ5_alt", "PAQ6_alt", "PAQ7_alt", "PAQ8_alt"]

deu_eq = circumplex.ssm_analyse(deu_data, scales, alt_scales, angles=eq_angles)
deu_corr = circumplex.ssm_analyse(deu_data, scales, alt_scales, angles=ang_dict['deu'])
Code
ell_data = pd.read_excel(
    data_folder / "SATP Dataset v1.4.xlsx",
    sheet_name="ell TUC",
    usecols = [
        "Participant", "Recording",
        "PAQ1", "PAQ2", "PAQ3", "PAQ4", "PAQ5", "PAQ6", "PAQ7", "PAQ8",
        "Uneventful 2", "Eventful 2", "Monotonous 2", "Uneventful 3", "Eventful 4"
    ]
)

ell_data.rename(columns = {
    "Uneventful 2": "PAQ7_alt1",
    "Eventful 2": "PAQ3_alt1",
    "Monotonous 2": "PAQ6_alt1",
    "Uneventful 3": "PAQ7_alt2",
    "Eventful 4": "PAQ3_alt2",
}, inplace=True)

alt_scales = ["PAQ3_alt1", "PAQ3_alt2", "PAQ6_alt1", "PAQ7_alt1", "PAQ7_alt2"]
alt_target_angles = [86.0, 86.0, 233.0, 267.0, 267.0]

ell_eq = circumplex.ssm_analyse(ell_data, scales, alt_scales, angles=eq_angles)
ell_corr = circumplex.ssm_analyse(ell_data, scales, alt_scales, angles=ang_dict['ell'])


ell_eq.plot()
ell_corr.plot()
(<Figure size 672x480 with 1 Axes>, <PolarAxes: >)

Code
print(procrustes_congruence(ell_eq.table, target_angles=alt_target_angles))
print(procrustes_congruence(ell_corr.table, target_angles=alt_target_angles))
0.921
0.92
Code
lang_rec_means = satp.groupby(["Language", "Recording"])[scales].mean()
lang_rec_means.reset_index(inplace=True)

base_lang = "arb"
secondary_lang_means = satp.groupby(["Recording"])[scales].mean().reset_index(drop=True)
# secondary_lang = 'eng'

for base_lang in lang_rec_means.Language.unique():
    test_res = []
    base_lang_means = lang_rec_means.query("Language == @base_lang")[scales].reset_index(drop=True)
    for scale in scales:
        # corrs = base_lang_means.corrwith(
        #     lang_rec_means.query("Language == @secondary_lang")[scale].reset_index(drop=True)
        #     )
        corrs = base_lang_means.corrwith(secondary_lang_means[scale])
        # ssm_res = circumplex.ssm_parameters(corrs.values[1:], eq_angles)
        ssm_res = circumplex.SSMParams(
            corrs,
            scales,
            ang_dict[base_lang],
            scale,
        )
        test_res.append(ssm_res)

    print("======= " + base_lang + " =======")
    test_res = circumplex.SSMResults(test_res)
    print(f"Congruence eq angles:   {procrustes_congruence(test_res.table, eq_angles)}")
    print(f"Congruence corr angles: {procrustes_congruence(test_res.table, ang_dict[base_lang])}")
    test_res.plot()
    plt.show()
======= arb =======
Congruence eq angles:   0.983
Congruence corr angles: 0.919
======= cmn =======
Congruence eq angles:   0.99
Congruence corr angles: 0.853
======= deu =======
Congruence eq angles:   0.985
Congruence corr angles: 0.981
======= ell =======
Congruence eq angles:   0.982
Congruence corr angles: 0.978
======= eng =======
Congruence eq angles:   0.984
Congruence corr angles: 0.984
======= hrv =======
Congruence eq angles:   0.986
Congruence corr angles: 0.96
======= ind =======
Congruence eq angles:   0.983
Congruence corr angles: 0.946
======= ita =======
Congruence eq angles:   0.977
Congruence corr angles: 0.957
======= kor =======
Congruence eq angles:   0.982
Congruence corr angles: 0.924
======= nld =======
Congruence eq angles:   0.981
Congruence corr angles: 0.951
======= spa =======
Congruence eq angles:   0.98
Congruence corr angles: 0.985
======= swe =======
Congruence eq angles:   0.98
Congruence corr angles: 0.987
======= tur =======
Congruence eq angles:   0.984
Congruence corr angles: 0.934
======= vie =======
Congruence eq angles:   0.959
Congruence corr angles: 0.854

Citation

BibTeX citation:
@online{mitchell2023,
  author = {Mitchell, Andrew and Aletta, Francesco},
  title = {Supplementary {Material} {(B)} - {Testing} the {Circumplex}
    {Structure} of the {Soundscape} {Survey}},
  date = {2023-11-21},
  langid = {en}
}
For attribution, please cite this work as:
Mitchell, Andrew, and Francesco Aletta. 2023. “Supplementary Material (B) - Testing the Circumplex Structure of the Soundscape Survey.” November 21, 2023.